Skip to content

WEBRC congestion control (RFC 3738), unverified: no conformance claimed - #98

Draft
jordijoangimenez wants to merge 12 commits into
feature/wave3-rfc6726-compliance-fixesfrom
feature/webrc-congestion-control
Draft

WEBRC congestion control (RFC 3738), unverified: no conformance claimed#98
jordijoangimenez wants to merge 12 commits into
feature/wave3-rfc6726-compliance-fixesfrom
feature/webrc-congestion-control

Conversation

@jordijoangimenez

@jordijoangimenez jordijoangimenez commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Base branch. This pull request is based on feature/wave3-rfc6726-compliance-fixes, the head of
#62, not on development, because it builds on the profile model added there. The diff therefore
shows only this pull request's own twelve commits. GitHub will retarget it when #62 merges.

Split out of #62, where the first six of these commits originally landed. They were separated
because they are a self-contained subsystem, they close no issue, they touch nothing either 3GPP
profile uses, and they are the only part of that work that is unverified against a second
implementation
. Keeping them there would have forced a reviewer to accept a congestion controller
that cannot be validated here alongside conformance fixes that are ready.

Dependencies and issues

Depends on: #62, and is based on its branch, so #62 merges first.
Paired with: #61, #64, #65 and #68, independent of this one, either order.
Merge order note: this touches Receiver and AlcPacket, which #61 and #68 also touch. Whichever
of the three merges last will need its own conflict pass; none of them depends on another's content.
Closes on merge: nothing. No issue tracks this; it is the last mandatory obligation of RFC 3450
clause 2.2 rather than a reported defect.

What this is

The WEBRC congestion control building block, RFC 3738. Twelve commits.

Commit What it adds
receiver: join and leave a session's multicast channels at runtime membership as an operation rather than a constructor step, source-specific and any-source, both address families
transmitter: carry more than one channel in a session add_channel() / remove_channel(), channel 0 the base channel
webrc: the sender-side schedule of RFC 3738 the clause 3.1 derived quantities, wave activity and rates, as pure arithmetic
alc: carry Congestion Control Information when a building block supplies it the clause 5.1 short format, CTSI / CN / PSN
transmitter: run the WEBRC sender over the session's channels enable_webrc(), one channel per wave channel, per-channel sequence numbers
webrc: the receiver control loop of RFC 3738 clause 3.2 loss and round-trip estimates, rate equation, target rate, join decision
webrc: keep the FDT on the base channel, not spread over the waves clause 3.1's base channel carries the FDT, so a late receiver can still bootstrap
webrc: send to each channel at the rate the schedule dictates credit-weighted channel selection, so the wave shape reaches the wire and not only the wave schedule
webrc: slow start, so the threshold rate stops being infinite clause 3.2.2.6, its start-up exits, and clause 3.2.3.4's adjustment at a loss event
receiver: run WEBRC, and refuse a session that has no congestion control the controller drives real joins, and a session outside the 3GPP profiles is refused without it
receiver: act on the CCI only after the packet's session has been identified clause 4.5's step 2 before its step 3, so a foreign session on the same group cannot move this receiver's rate
webrc: the two remaining exits from start-up, and the loss reset all four share clause 3.2.2.6's join-delay and true-rate exits, and the loss-variable reset attached to all four

Why it exists

RFC 3450 clause 2.2, which binds plain ALC:

Implementors of ALC MUST implement a multiple rate feedback-free congestion control building block
that is in accordance to RFC 2357 [12].

RFC 5775 clause 2.2, which binds FLUTE version 2, names the building block:

At a minimum, implementations of ALC MUST support [RFC3738].

The last commit acts on the receiver half of the same obligation. RFC 3450 clause 4.5:

The receiver MUST process and act on the CCI field in accordance with the multiple rate congestion
control building block.

and, for a receiver that cannot:

If a receiver is not able to implement the multiple rate congestion control building block it MUST
NOT join the session.

RFC 3451 clause 6.2 obliges the same at the LCT layer. Receiver's constructor therefore throws
under Profile::Unprofiled when no WEBRC session channels are supplied, rather than joining a
session it cannot run congestion control for. That is a behaviour change for any caller using
Unprofiled: it turns a silent violation into an explicit refusal the caller must resolve.

Not for the 3GPP profiles, which refuse it

enable_webrc() throws under Profile::Ts26517 and Profile::Ts26346, and those profiles are
exempt from the receiver-side refusal above. TS 26.346 clause 7.2.4:

For simplicity of congestion control, FLUTE channelization shall be provided by a single FLUTE
channel with single rate transport.

Clause 7.2.7 fixes the CCI at a 32-bit zero besides. So this changes nothing for MBSTF, rt-mbms-mw,
or any other 3GPP consumer: their sessions behave exactly as before, on one channel with a zero CCI,
which is what those clauses require, and they construct Receiver exactly as they do today.

No conformance is claimed, and none should be

The three gaps this pull request originally listed are now closed: the sender weights channels by the
per-slot rates, slow start is implemented, and the receiver controller drives real joins. What
remains is not a list of missing pieces but a structural limit:

A rate-control loop cannot be validated against itself. Every test here checks a formula against
the clause that states it, which catches transcription errors and nothing else. Whether the loop
converges, backs off, or oscillates against a real peer on a real network is untested and untestable
in this repository. RFC 3738 is also Experimental, and wrong congestion control degrades other
people's networks, not only the sender's.

Correction to an earlier version of this description. It listed the two remaining exits from
start-up as RECOMMENDED. One of them is not. RFC 3738 clause 3.2.2.6 on the join-delay exit:

While SSR_P = infinity the receiver MUST compute, in the notation of Section 3.2.2.2, differences in successive measurements of (FirstTime-JoinTime) from successive waves and MUST set SSR_P to max{SSMINR_P, P*TRR_P} when a large increase in (FirstTime-JoinTime) is observed.

Only the size that counts as large is RECOMMENDED. Both exits, and the loss-variable reset the clause
attaches to all four, are now implemented.

Still absent, and each recorded at its site:

  • Clause 3.1.2's first-two-slots deviation, which is a MAY.
  • Leaving a wave channel on the controller's own signal. The controller reports the decision; the
    receiver acts on the join side only.

Treat Profile::Unprofiled and FLUTE version 2 as non-conformant on RFC 3450 clause 2.2 and
RFC 5775 clause 2.2 until this has been tested against a second implementation.

What would close it

Test against another implementation. Nothing else is known to be missing.

Verification

T1: 128 cases pass on this branch, 66 of them new. Each formula is checked against the clause that
states it, including the derived quantities of clause 3.1.1 recomputed independently in the test,
exactly N wave channels active in every slot of a full cycle, the rate equation of clause 3.2.2.3,
each mandatory refusal of clause 3.2.3.6 shown separately to change the answer, both slow-start exits
that are implemented, the refusal to join without congestion control, and the clause 5.1 short-format
CCI read back off a hand-built packet.

Two of the newer cases are worth naming because they fail against the code they replace rather than
passing regardless. The step-ordering case sends twenty packets carrying a foreign TSI and then five of
this session's own, asserting that the congestion control loop saw 0 and then 5; against the previous
ordering it sees 20. The three start-up-exit cases each hold the other exits off, so an exit that fired
for the wrong reason shows up as a failed assertion rather than a pass.

What that verification does not cover: any running session, any interaction with a real peer, and
the control loop's behaviour over time. See above.

Change type

New feature, not for 3GPP MBS or MBMS use, and not yet fit to enable in a deployment.

Problem
  The receiver joined one multicast group in its constructor and held it for the life of the
  session, with the join logic inline there and no way to reach it again. A multiple rate
  congestion control building block works by moving a receiver between a session's channels, so
  that shape makes one impossible to add.   [code-derived]

Basis
  RFC 5775 clause 2.1, on the session shape the building block assumes: "An ALC session comprises
  multiple channels originating at a single sender"

  RFC 3450 clause 2.2 requires such a building block for plain ALC, and RFC 5775 clause 2.2 names
  RFC 3738 for version 2. Neither is implemented here and neither is claimed. This is the
  prerequisite they both need, and it is useful on its own for a session announced on more than one
  address.

Raised by
  reading the authority during this work

Change
  Receiver owns the socket, so the membership belongs there. The constructor's inline join, which
  handled source-specific and any-source multicast over both families, is extracted into
  set_group_membership(group, join) and now serves leaving as well: the source-specific paths gain
  their IP_DROP_SOURCE_MEMBERSHIP and MCAST_LEAVE_SOURCE_GROUP counterparts, the any-source paths
  their leave_group(). join_channel() and leave_channel() expose it, keeping the set of joined
  groups so a redundant call is a no-op that reports no change rather than a socket error.

  The interface, and the source where the session is source-specific, are retained from
  construction so a later join uses the same ones. No behaviour changes for a session that never
  calls the new methods: the constructor takes the same path it did, through the extracted function.

Verification
  T1: 66 cases pass, 3 new, covering the constructed group being recorded as joined, a further
  channel joined and left, and redundant calls reporting no change. The existing suite, including
  the end-to-end multicast transfers, passes unchanged, which is what shows the extraction did not
  alter the constructor's behaviour.

Not in this change
  Nothing calls these. No congestion control building block, no WEBRC, no CCI content: the field is
  still sent as zeros, which is correct while no building block defines it. Nothing here claims
  conformance to RFC 3450 clause 2.2 or RFC 5775 clause 2.2.
Problem
  Transmitter held one socket and one destination for the life of the session, so a session could
  only ever have one channel. A multiple rate congestion control building block sends to several
  channels at different rates and lets each receiver choose how many it joins, which that shape
  makes impossible.   [code-derived]

Basis
  RFC 5775 clause 2.1, on the session shape such a building block assumes: "An ALC session comprises
  multiple channels originating at a single sender"

  RFC 3450 clause 2.2 requires such a building block for plain ALC and RFC 5775 clause 2.2 names
  RFC 3738 for version 2. Neither is implemented here and neither is claimed. This is the sending
  half of the prerequisite they need; the receiving half landed in the previous commit.

Raised by
  reading the authority during this work

Change
  add_channel() and remove_channel(), with channel 0 the one given at construction, which is WEBRC's
  base channel and cannot be removed: a session with no channels is not a session. An added channel
  takes the same socket options and source-address binding as the first, so every channel leaves
  from the announced source and behaves alike.

  Nothing sends on an added channel. The send path is untouched and still uses channel 0, so a
  session that never calls these behaves exactly as before, which the unchanged end-to-end transfers
  demonstrate.

Verification
  T1: 69 cases pass, 3 new, covering a session starting with one channel, channels added and removed
  with the indices behaving as documented, and the constructed channel and a nonexistent one both
  refusing removal.

Not in this change
  No sending on added channels, no rate per channel, no time slots, no CCI content: the field is
  still zeros, which is correct while no building block defines it. Nothing claims conformance to
  RFC 3450 clause 2.2 or RFC 5775 clause 2.2.
Problem
  Congestion control is the one mandatory obligation the library does not meet for a session
  outside the 3GPP profiles. Nothing of the building block existed, so there was nothing for a
  sender to drive channels from.   [source-derived]

Basis
  RFC 5775 clause 2.2: "At a minimum, implementations of ALC MUST support [RFC3738]."

  RFC 3450 clause 2.2 imposes the same obligation without naming a building block for plain ALC.
  RFC 3738 clause 3.1.1 gives the inputs and the quantities derived from them, and clause 3.1.2 the
  wave shape: "wave channel i is active during time slots i-N+1 modulo T, i-N+2 modulo T, ..., i and
  is quiescent for time slots i+1 modulo T, i+2 modulo T, ..., i+Q modulo T."

  Not needed by a 3GPP profile: TS 26.346 clause 7.2.4 excludes congestion control for MBMS
  download, and clause 7.2.7 fixes the CCI at a 32-bit zero.

Raised by
  reading the authority during this work

Change
  A new Webrc namespace holding the sender's schedule and nothing else: the derived quantities
  SR_P, BCR_b, L, Q and T from clause 3.1.1, which channels are active in a given time slot, and
  the rate of the base channel and of a wave within a slot. Pure arithmetic with no I/O, so it is
  testable against the clause's own formulas without a running session.

  Inputs are validated rather than trusted. P at exactly 1 makes L undefined through log(P), and a
  cycle longer than 255 slots cannot be expressed in the 8-bit channel number of the short-format
  Congestion Control Information, since the base channel takes the value T. Both are refused.

Verification
  T1: 86 cases pass, 17 new. The derived quantities are checked against the clause's formulas
  recomputed independently in the test, including the recommended TSD of 10 and QD of 300 giving
  Q=30 and T=34; the wave schedule is checked for its own N slots, for wrapping around the cycle,
  and for exactly N wave channels being active in every slot of a full cycle; the rates for the
  endpoints the clause states, for continuity across a slot boundary and for strict decrease within
  a slot.

Not in this change
  Nothing sends. No packet carries this: the Congestion Control Information field is still zeros.
  The receiver half of RFC 3738, clause 3.2, is absent entirely. Nothing here claims conformance to
  RFC 3738, RFC 3450 clause 2.2 or RFC 5775 clause 2.2, and the deviation the clause permits at the
  start of a wave to hold the aggregate rate constant is not implemented.
…ies it

Problem
  The Congestion Control Information field was always sent as zeros, with no way for a congestion
  control building block to put anything in it. RFC 3738's channels cannot be told apart by a
  receiver without it.   [code-derived]

Basis
  RFC 3738 clause 5.1 gives the short format as three fields totalling 32 bits, which is the width
  the LCT header's C=0 already selects, so carrying it costs nothing the header did not spend:
  "CTSI indicates the index of the current time slot." "CN is the channel number that this packet
  belongs to." "The PSN of each packet is scoped by its CN value."

  Zeros remain correct where no building block is running, and are required of a 3GPP session.
  TS 26.346 V18.2.0 clause 7.2.7: "-The length of the CCI (Congestion Control Identifier) field
  shall be 32 bits and it is assigned a value of zero (C=0)."

Raised by
  reading the authority during this work

Change
  AlcPacket takes an optional CongestionControlInfo and writes CTSI, channel number and a
  network-order packet sequence number into the field when given one. Omitted, nothing is written
  and the already-zeroed buffer stands, so every existing caller including every 3GPP session sends
  exactly what it sent before.

Verification
  T1: 89 cases pass, 3 new, covering the field left zero when absent, each byte in the order the
  clause lays out when present, and the 2^16-1 sequence number the clause reserves for the last
  packet of a wave.

Not in this change
  Nothing supplies one. The transmitter does not yet drive channels from the schedule, so no packet
  the library sends carries a non-zero field. No conformance to RFC 3738 is claimed.
Problem
  The WEBRC schedule existed but nothing drove it: every packet still went to the session's single
  destination with a zero Congestion Control Information field, so a receiver had no way to tell one
  channel of a session from another.   [code-derived]

Basis
  RFC 5775 clause 2.2: "At a minimum, implementations of ALC MUST support [RFC3738]."

  RFC 3738 clause 5.1 on what each packet carries: "CTSI indicates the index of the current time
  slot." and "The Current Time Slot Index increases by one modulo T each TSD seconds at the sender".
  Clause 3.1.2 on the channel numbering: "Recall that CN = T for the base channel and CN =
  0,1,...,T-1 for the wave channels."

  Refused under a 3GPP profile, which excludes congestion control altogether.

  TS 26.346 V18.2.0 clause 7.2.4: "For simplicity of congestion control, FLUTE channelization shall
  be provided by a single FLUTE channel with single rate transport."

Raised by
  reading the authority during this work

Change
  enable_webrc() takes the parameters and one address per wave channel, opens a channel for each,
  and starts the time slot clock. The session's own destination becomes the base channel and takes
  CN = T. Each packet then goes to one of the channels active in the current slot and carries the
  Congestion Control Information naming it, with a per-channel sequence number counting
  consecutively modulo 2^16.

  The number of addresses must match the T the parameters derive, and a mismatch is refused at
  setup: a session sending waves to the wrong number of channels is not the session its description
  announces.

Verification
  T1: 94 cases pass, 5 new, covering refusal under both 3GPP profiles, one channel opened per wave
  channel with the base channel making T+1, refusal of the wrong number of addresses, the base
  channel taking CN = T while added channel i is wave channel i-1, and no Congestion Control
  Information before the building block is enabled.

Not in this change
  Channels active in a slot are taken in turn rather than weighted by the per-slot rates of clause
  3.1.2, so the wave shape is not yet reproduced on the wire; the rate functions exist and are
  tested but nothing calls them. The deviation clause 3.1.2 permits at the start of a wave is not
  implemented. The receiver half, clause 3.2, is absent. No conformance to RFC 3738, RFC 3450 clause
  2.2 or RFC 5775 clause 2.2 is claimed, and none should be until the wave shape and a receiver
  exist and have been tested against another implementation.
Problem
  The sender half of the building block ran, but nothing on the receive side decided which channels
  to be joined to, which is where a multiple rate congestion control building block does its
  work.   [code-derived]

Basis
  RFC 3738 clause 3.2.2.1 gives the loss estimate as two filters over packet and loss counts, with
  the update rules stated as formulas over W, X, Y and Z and the recommended smoothing constants
  Nu = 0.3 and Delta = 0.3.

  RFC 3738 clause 3.2.2.2 gives the round-trip estimate: "ARTT is updated to
  max{P*ARTT,(1-Rho)*ARTT+Rho*MRTT}", with Alpha recommended at 0.25.

  RFC 3738 clause 3.2.2.3: "REQN = 1/(ARTT*sqrt{LOSSP}(0.816 + 7.35*LOSSP*(1+32*LOSSP^2)))"

  RFC 3738 clause 3.2.2.7: "TRATE = min{max{SSR_P, REQN}, MRR_P}. When SSR_P = infinity, TRATE is
  computed as TRATE = min{4*TRR_P, MRR_P}."

  RFC 3738 clause 3.2.3.6 gives the join decision, including "If NWC = N the receiver MUST not
  join." and the rate inequality against ARR_P.

Raised by
  reading the authority during this work

Change
  Webrc::ReceiverController, holding the loss and round-trip estimates, the rate equation, the
  target rate and the join decision with the mandatory refusals of clause 3.2.3.6.

  It performs no I/O and joins nothing. It is fed events and answers whether a join is permitted,
  and the caller acts or does not. That is deliberate rather than unfinished: a rate control loop
  cannot be validated against itself, and wiring it to Receiver's joins would put an unverified
  control loop in charge of how much bandwidth a deployment takes.

Verification
  T1: 105 cases pass, 11 new. The loss estimate is checked for staying bounded without loss and
  rising with frequent loss events; the rate equation against the clause's formula recomputed in the
  test; the base channel setting the round trip directly and the floor holding against a negative
  measurement, which the clause says can occur; the target rate during start-up and its cap; and
  each mandatory refusal of clause 3.2.3.6 separately, each shown to change the answer.

Not in this change
  Nothing calls it. The optional checks of clause 3.2.3.6, on RR_P against its maximum since the
  last join and on TRR_P being greatly below ARR_P, are not applied, and the constant-aggregate-rate
  variant of the inequality is not distinguished, the stricter form being used. Slow start keeps
  SSR_P at infinity: clause 3.2.3.4's adjustment at a loss event is absent. The reception rates of
  clause 3.2.2.5 are supplied by the caller rather than measured here.

  No conformance to RFC 3738, RFC 3450 clause 2.2 or RFC 5775 clause 2.2 is claimed, and none should
  be until this has been tested against another implementation on a real network.
Problem
  The sender distributed every packet over the base channel and the active wave channels in turn,
  the FDT among them. A receiver that has not climbed above the base channel would then miss part of
  every FDT Instance, and since no object can be interpreted before the FDT describing it arrives,
  such a receiver would get nothing at all from the session. That is the opposite of what a
  congestion control building block is for: the receivers it exists to serve are exactly the ones it
  would have starved.   [code-derived]

Basis
  RFC 3926 clause 4, recommendation 1: "The layers to which packets for FDT Instances are sent
  SHOULD NOT be biased towards those layers to which lower rate receivers are not joined."

  The same recommendation names putting every FDT packet in the lowest layer as an acceptable way to
  satisfy it, which is what this does. Its recommendation 2, asking for an FEC scheme other than
  Encoding ID 0 where FDT packets do reach layers a lower rate receiver does not, is thereby moot;
  this sender always carries the FDT as Compact No-Code, so keeping it on the base channel is what
  makes that safe.

Raised by
  reading the authority during this work

Change
  An object with TOI 0 goes to the base channel and nowhere else. Content objects continue to spread
  over the base channel and the waves active in the current time slot.

Verification
  T1: 106 cases pass, 1 new, checking that the base channel's Congestion Control Information carries
  CN = T, which is what an FDT packet rides whatever the round-robin over content channels has
  reached.

Not in this change
  The round-robin over content channels is still unweighted by the per-slot rates of RFC 3738 clause
  3.1.2, and the other gaps recorded on this branch are unchanged. No conformance is claimed.
Problem
  Packets were spread over the base channel and the active wave channels in equal turns. The
  schedule computes a rate per channel, and those rates differ by a factor of P per slot of a wave's
  remaining life, so equal turns put the wrong share on every channel and the wave shape never
  reached the wire at all. Only the wave schedule did.   [source-derived]

Basis
  RFC 3450 clause 4.4: "The ALC sender MUST obey the rules for filling in the CCI field in the
  packet headers and MUST send packets at the appropriate rates to the channels associated with the
  session as dictated by the multiple rate congestion control building block."

  RFC 3738 clause 3.1.2 is what dictates them: a wave's rate falls by a factor of P per time slot
  down to the base channel's rate in its last active slot, and the base channel falls from BCR_P to
  P*BCR_P across each slot.

Raised by
  reading the authority during this work

Change
  Each channel accrues credit at its own current rate and spends one credit per packet, so the share
  each receives converges on its share of the total rate. A wave with more active slots to run
  therefore takes more packets than one about to go quiescent, and both take more than the base
  channel, which is the shape the clause describes. The rate functions were already present and
  tested; this is what calls them.

  The FDT is unaffected and still goes to the base channel alone, for the reason in the previous
  commit.

Verification
  T1: 107 cases pass, 1 new, checking the ordering the credit scheme depends on: a younger wave
  outruns an older one, both outrun the base channel, and one slot of age is exactly a factor of
  1/P, which is the ratio the shares converge on.

Not in this change
  The deviation RFC 3738 clause 3.1.2 permits over the first two slots of a wave, to hold the
  aggregate rate constant, is still not implemented; the exponential is used throughout. Slow start
  is still absent and nothing still calls the receiver controller. No conformance is claimed.
Problem
  SSR_P was initialised to infinity and never left it, so the receiver stayed in the start-up period
  for the life of the session. Its target rate was therefore always four times the target reception
  rate, the rate equation never entered the decision, and a loss event lowered nothing.
  [source-derived]

Basis
  RFC 3738 clause 3.2.3.4: "When a start of a loss event is detected, the value of SSR_P is updated
  to max{SSMINR_P, P*TRR_P}."

  RFC 3738 clause 3.2.2.6, on the floor: "The recommended value for SSMINR_P is BCR_P*(1+1/P+1/P^2)."

  The same clause, on the other event now implemented: "When SSR_P = infinity, if
  (P^(-NWC-2)-1)/(P^(-NWC-1)-1)*ARR_P exceeds MRR_P or SR_P, the receiver MUST set SSR_P to
  max{SSMINR_P, TRR_P}."

Raised by
  reading the authority during this work

Change
  A loss event ends the start-up period and sets the threshold to the greater of the floor and P
  times the target reception rate, and every later loss lowers it again. Reaching the maximum
  reception rate ends it too, checked once an epoch where the quantities it needs are already
  current. The floor is exposed so a caller can see what the threshold is held to.

Verification
  T1: 113 cases pass, 6 new: the threshold starting infinite, the floor matching the clause's
  formula, a loss event ending start-up and setting the value the clause gives, the floor holding
  when P times the target rate would be lower, the maximum reception rate ending start-up, and the
  target rate switching between the two branches of clause 3.2.2.7 as a result.

Not in this change
  The two remaining start-up exits of clause 3.2.2.6, a sharp increase in the round-trip measurement
  and the target reception rate failing to rise after a join, are not implemented; both are phrased
  as RECOMMENDED thresholds over measurements this class is given rather than takes. The reset of
  the loss variables that the same clause asks for on leaving start-up is also absent. Nothing calls
  the controller, and no conformance to RFC 3738 is claimed.
Problem
  Receiver joined every session unconditionally and never looked at the CCI field, so a
  non-3GPP session ran with no congestion control at all. src/Receiver.cpp, constructor.
  [code-derived]

Basis
  RFC 3450 clause 4.5:
  "The receiver MUST process and act on the CCI field in accordance with the multiple rate
  congestion control building block."

  RFC 3450 clause 4.5:
  "If a receiver is not able to implement the multiple rate congestion control building block
  it MUST NOT join the session."

  RFC 3451 clause 6.2:
  "If a receiver is not able to implement the congestion control protocol used in the session,
  it MUST NOT join the session."

  Neither binds the 3GPP profiles, which run no congestion control.
  TS 26.346 V18.2.0 clause 7.2.4:
  "a single FLUTE channel with single rate transport"

  Loss detection, RFC 3738 clause 3.2.3.4:
  "Each time the receiver detects a lost packet (based on the sequence numbers in the packets
  scoped by the channel number), the receiver records the start of a new loss event"

  Short-format CCI layout, RFC 3738 clause 5.1.

Raised by
  reading the authority during this work

Change
  Receiver's constructor takes the session Profile and, for a session outside the 3GPP
  profiles, the WEBRC session channels. Without them it throws rather than joining, which is
  the clause's own remedy. With them it builds a ReceiverController, feeds every accepted
  packet's CCI into the loss and packet-event inputs, and runs an epoch timer whose decision
  drives the existing join_channel()/leave_channel() calls. AlcPacket now exposes the CCI it
  parsed; the field was read off the wire and discarded.

  Receiver owns this because Receiver is what joins the session, and the clause forbids the
  join, not the reception.

Verification
  T1: flute_webrc_tests, 116 cases across the suite pass, three of them new (refusal without
  congestion control, wave-channel count check, short-format CCI read-back).

Not in this change
  The two RECOMMENDED start-up exits of RFC 3738 clause 3.2.2.6, the loss-variable reset on
  leaving start-up, and clause 3.1.2's first-two-slots deviation, which is a MAY. Leaving a
  wave channel on the controller's own signal: the controller reports the decision, the
  receiver acts only on the join side so far.
@jordijoangimenez
jordijoangimenez force-pushed the feature/webrc-congestion-control branch from f20c4ff to 0774caa Compare August 23, 2026 15:41
…ntified

Problem
  The congestion control step ran before the TSI was matched against this session's own, so a
  packet arriving on the session's group and carrying a different session's TSI was fed to this
  session's congestion controller. The sender-address check was already in the right place, before
  the parse; the TSI match was not.

  Observed: twenty packets carrying a foreign TSI, delivered to a live receiver, raised
  webrc_packets_noted() to 20. It should stay at 0.   [observed]

Basis
  RFC 3450 clause 4.5 sets out the receiver's steps as a numbered sequence.

  Step 2 disposes of a packet whose sender address and TSI do not together match the session.
  RFC 3450 clause 4.5:
  "If there is not a match then the packet MUST be discarded without further processing."

  RFC 3450 clause 4.5, step 3, which is the step this code performs:
  "The receiver MUST process and act on the CCI field in accordance with the multiple rate
  congestion control building block."

  Acting on the CCI of a packet that step 2 required be discarded is further processing of it, so
  the sequence is load-bearing rather than presentational. On an any-source group, where the kernel
  filters on the group and not the sender, the earlier order let any host on the network move this
  receiver's reception rate.

Raised by
  reading the authority during this work

Change
  note_webrc_packet() moves inside the block guarded by the TSI match, which is where step 3
  belongs.

  Receiver gains webrc_packets_noted(), a counter of the packets whose CCI reached the congestion
  control loop. It exists so the step order can be tested: the loop's own state is private, and
  without a count of what entered it a test cannot distinguish a packet that was correctly ignored
  from one that was never delivered.

Verification
  T1: flute_webrc_tests, 121 cases in the suite pass, one new. The new case delivers twenty packets
  carrying a foreign TSI and then five carrying this session's own, asserting the counter reads 0
  and then 5. Against the previous order it reads 20 at the first assertion, so the case measures
  the order rather than merely exercising the plumbing.

Not in this change
  The two exits from start-up of RFC 3738 clause 3.2.2.6 that this branch has not yet implemented.
  One of them is required and one recommended, and both are the subject of the commit that follows
  this one.
…four share

Problem
  RFC 3738 clause 3.2.2.6 gives four events that end the start-up period. Two were implemented, a
  detected packet loss and the maximum reception rate being reached. Two were not:

    - a sharp rise in the delay between joining a wave channel and its first packet arriving, which
      the clause makes mandatory;
    - the true reception rate failing to rise after a wave was joined, which it recommends.

  The clause also attaches a reset of the loss variables to all four events, and that was absent
  entirely, so a receiver that left start-up carried forward a loss estimate bearing no relation to
  the rate it was actually receiving. src/Webrc.cpp, on_epoch_end() and on_loss_event_begin().
  [code-derived]

Basis
  RFC 3738 clause 3.2.2.6, the mandatory exit that was missing:
  "While SSR_P = infinity the receiver MUST compute, in the notation of Section 3.2.2.2,
  differences in successive measurements of (FirstTime-JoinTime) from successive waves and MUST set
  SSR_P to max{SSMINR_P, P*TRR_P} when a large increase in (FirstTime-JoinTime) is observed."

  Only the size that counts as large is left to the implementer, and the clause recommends one.
  RFC 3738 clause 3.2.2.6:
  "It is RECOMMENDED that an increase in (FirstTime-JoinTime) be considered large if it exceeds
  (P^(NWC+1)-1)/(P*log(P)) / ARR_P."

  RFC 3738 clause 3.2.2.6, the recommended exit, in two parts. When to wait:
  "While SSR_P = infinity, it is RECOMMENDED that the receiver wait at least one full epoch after
  the first packet of a wave is received before joining the next wave."

  RFC 3738 clause 3.2.2.6, on what to do if that wait shows no gain:
  "If the TRR_P after that full epoch is greatly below ARR_P the receiver SHOULD NOT join and
  SHOULD then set SSR_P to max{SSMINR_P, TRR_P}."

  RFC 3738 clause 3.2.2.6, the reset the four events share:
  "In any of these four cases, the variables associated with LOSSP are reset"

  The same sentence names the target: REQN, computed by clause 3.2.2.3 with the current ARTT, is to
  come out equal to TRR_P.

  The comparison in the recommended exit needs Zeta, whose start-up value the clause also
  recommends. RFC 3738 clause 3.2.2.1:
  "In start-up mode, it is RECOMMENDED that Beta = (1 - P^(0.25))/2 and Zeta = sqrt(P)/(1 +
  sqrt(P))."

Change
  The mandatory exit: on_join_measured() keeps the previous wave's join-to-first-packet delay,
  differences it against the next, and ends start-up when the rise exceeds the recommended bound.

  The recommended exit is split across two methods for one reason. may_join_next_layer() is the
  join decision and is const, so it can refuse but cannot record that it refused; it now refuses
  both while the newest wave has had less than a full epoch and when the true rate stayed greatly
  below the anticipated one. note_start_up_progress(), which the receiver calls once an epoch, is
  what then ends start-up.

  All four events now end start-up through leave_start_up(), which sets the threshold rate and
  performs the shared reset. The reset needs the loss probability that puts REQN at the true
  reception rate; REQN falls monotonically as that probability rises and clause 3.2.2.3's
  expression has no closed-form inverse, so it is found by bisection over fifty halvings.

  A loss event arriving after start-up has already ended does not reset again. The clause scopes the
  reset to "any of these four cases", and the four cases are its exits from start-up, not every
  later loss.

  Receiver reports the first packet of each newly joined wave, since that arrival is what the full
  epoch is measured from.

Raised by
  reading the authority during this work

Verification
  T1: flute_webrc_tests, 128 cases in the suite pass, seven new.

    - the mandatory exit, with a rise above the bound and a rise below it;
    - the epoch wait, asserted before the epoch has elapsed and again after;
    - the true-rate comparison in both directions;
    - the shared reset, asserting REQN comes out at the true reception rate;
    - a later loss event, asserting it leaves the loss estimate alone.

  The three cases covering the recommended exit each hold the other three exits off, by setting the
  maximum reception rate out of reach and keeping the anticipated rate below the projection that
  would trigger the maximum-rate exit. Without that, an exit firing for the wrong reason would still
  pass, and one of these cases was failing that way when first written.

Not in this change
  Clause 3.1.2's permission to deviate over a wave's first two time slots, which is a MAY.

  Leaving a wave channel on the controller's own signal. The controller reports the decision; the
  receiver acts on the join side only, which is recorded as a limitation rather than passed over.
@jordijoangimenez
jordijoangimenez force-pushed the feature/webrc-congestion-control branch from 0774caa to 2d16781 Compare August 23, 2026 15:56
@jordijoangimenez

Copy link
Copy Markdown
Contributor Author

This pull request: keep it in draft. Do not merge it.

Status: draft, and it should stay in draft indefinitely. 12 commits, 12 files, +2266/-78. 128 cases pass. It is complete as an implementation and that is precisely why it should not be merged yet.

What to do

  1. Leave it in draft. Merging it would put an unvalidated rate-control loop into a library other people deploy. Wrong congestion control degrades other people's networks, not only the sender's.
  2. Do not treat the passing tests as conformance. Every case here checks a formula against the clause that states it. That catches transcription errors and nothing else. Whether the loop converges, backs off, or oscillates against a real peer on a real network is untested and untestable in this repository.
  3. What would close it: one interoperability test against a second WEBRC implementation. Nothing else is known to be missing. Everything else that was outstanding when this was opened, the sender's per-slot rate weighting, slow start, wiring the receiver controller to real joins, and the two remaining exits from start-up, is now implemented.
  4. Until that test exists, Profile::Unprofiled and FLUTE version 2 should be recorded as non-conformant on RFC 3450 clause 2.2 and RFC 5775 clause 2.2, and the library should not claim otherwise.

It changes nothing for 3GPP consumers. enable_webrc() throws under Profile::Ts26517 and Profile::Ts26346, because TS 26.346 clause 7.2.4 requires a single FLUTE channel with single rate transport. MBSTF, rt-mbms-mw and any other 3GPP consumer construct Receiver exactly as they do today.

One behaviour change to be aware of if it ever does merge. A session outside the 3GPP profiles is now refused at construction when no WEBRC channels are supplied, because RFC 3450 clause 4.5 says a receiver that cannot implement the building block "MUST NOT join the session". That turns a silent violation into an explicit refusal a caller has to resolve.


How this set of pull requests came to look like this

When What Why
May 2023 #7, Forward Error Correction, from @autumn-traveller The original FEC contribution. Closed 11 Aug 2026 in favour of a from-scratch RFC 5053 implementation, after the decision to stop harmonising two divergent approaches. Its intent is carried by #61.
11 Aug 2026 #67, tunnel-mode reception + FEC + reception fixes One pull request holding three unrelated subjects, opened from a personal fork. Closed the same day and reopened as #68 from this repository.
11 Aug 2026 The stack below The remaining work split by subject, so each part could be reviewed on its own terms and merged in a stated order.
22 Aug 2026 #87 to #95 opened, then closed the same day Nine single-issue pull requests, an attempt to split the stack further. Each carries a closing comment naming the pull request its work was folded into, stating that the issue it closed is claimed by that pull request instead, and that nothing is dropped. The stated reason was to keep the number of open pull requests down. They still had to merge in a fixed order, so splitting them out raised the count from six to fifteen without making any one of them easier to review.
23 Aug 2026 #98 split out of #62 WEBRC congestion control cannot be validated in this repository. Keeping it in #62 would have forced a reviewer to accept an unverified congestion controller alongside conformance fixes that are ready.
23 Aug 2026 Four commits added to #62, two to #98 A prose read of RFC 5775 (ALC) and RFC 5651 (LCT) against the code, undertaken to scope #81's second step. Findings are listed on #81.

Where the nine closed pull requests went

Closed Its work is now in Issue Declared by
#87 RFC 5053 Raptor codec #61 #1, in part see the note below
#88 missing source symbol ESIs #61 #86 #61
#89 zlib encode and decode loops #68 #77 #68
#90 encoded object transfer length #68 #80 #68
#91 source address binding with a tunnel #62 #82 #62
#92 IPsec netlink socket leak #62 #83 #62
#93 FDT growth regression coverage #62 none, tests only n/a
#94 receiver example output path #62 #27 #62
#95 IPsec IPv6 destinations #62 #85 #62

One correction, @rjb1000. #93 is listed above as "already merged in PR #60". #60 merged the FDT growth fix on 17 August and changed no test file; tests/test_fdt_growth.cpp does not exist on development today. The regression coverage is commit 2750ff0 on #62's branch. Nothing is lost either way, but the coverage rides in with #62 rather than being already upstream.

Every open issue, and which pull request closes it

Closed on merge by Issues
#62 #27, #70, #71, #72, #73, #74, #75, #76, #82, #83, #85, #96, #97
#61 #86
#68 #66, #77, #78, #79, #80
#64, #65, #98 none

Four open issues are deliberately closed by nothing:

The stack, and the order to merge it in

development
└── #62  feature/wave3-rfc6726-compliance-fixes      merge FIRST
    ├── #61  feature/raptor-raptorq-fec              then this
    │   └── #64  feature/raptorq-support             then this
    ├── #65  feature/flute-v2-support                any time after #62
    ├── #68  feature/issue66-receiver-tunnel-mode    draft, see below
    └── #98  feature/webrc-congestion-control        draft, see below

GitHub retargets each child to development as its parent merges. All seven are mergeable, every branch builds clean, and each passes its own suite: #62 67 cases, #65 81, #61 97, #64 114, #68 77, #98 128.

One trap for whoever merges. The heads live in two repositories: #61, #68 and #98 in 5G-MAG, #62, #64 and #65 on a personal fork. The base branch feature/wave3-rfc6726-compliance-fixes exists in both, and the four pull requests based on it resolve it in 5G-MAG, not the fork. Both copies are at the same commit now and need to stay that way; a push to only one of them leaves four pull requests comparing against history that no longer exists.

@jordijoangimenez

Copy link
Copy Markdown
Contributor Author

Leave in draft, do not merge. It is complete as an implementation, which is the reason: merging puts an unvalidated rate-control loop into a library others deploy.

The passing tests are not conformance. Each checks a formula against the clause stating it. How the loop behaves against a real peer is untestable here.

One interoperability test against a second WEBRC implementation would close it. Nothing else is missing. Until then Profile::Unprofiled and FLUTE version 2 stay non-conformant on RFC 3450 clause 2.2 and RFC 5775 clause 2.2.

Nothing changes for 3GPP consumers: enable_webrc() throws under both 3GPP profiles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant